REQ-70: mirror the verified file gate into emitted C, and route fail/vfs - #73
Merged
Conversation
…te fail/vfs REQ-70. The file family was held back because routing it would have let a compiled binary bypass a Coq-modeled access-control check that riinac run enforces (12 gate call sites in the interpreter, 0 gating constructs in the C helpers). The gate is now mirrored, so the family routes. DESIGN, recorded because the obvious approach is impossible. Emitted C cannot call into Rust: the pipeline is `cc -o out one.c` with nothing linked, so a single shared implementation callable from both backends would mean shipping per-target Rust staticlibs (native/wasm32/android/ios) and rebuilding the compile pipeline. Instead the COQ MODEL is the single source of truth — domains/VerifiedFileSystem.v (Inode/Ownership/Permission/is_owner/ get_permission) — with riina-os/src/vfs.rs and the emitted C as two implementations of it, held together by a differential. Same shape as the masa civil calendar and the GF128/AES Coq-Rust equivalences. The C mirrors: the inode table, first-touch ownership at mode 0644 (owner rw, group/other r), owner > group > other resolution, can_read/can_write as is_root OR the applicable bit, and gate_delete clearing the mapping so a re-creator owns the file. Denial exits non-zero with the interpreter's wording and cannot fall through to the filesystem. Matched op-for-op, including the negative space: fail_ada and fail_senarai are UNGATED in the interpreter (an existence check is not an access), so they are ungated in C too. Gating them would have been a divergence in the opposite direction — denying what the interpreter allows. Deliberately NOT the host OS's own permission bits: the gate is RIINA's model and must deny where the model denies even when the host would allow (the process typically owns these files), so letting the kernel decide would silently make the check a no-op. Routed: the 8 fail_* builtins plus vfs_mula/vfs_jadi_pengguna, which are what make the gate meaningful. vfs_tulis/vfs_baca/vfs_padam stay interpreter-only — they operate on the in-memory VirtualFs with quota accounting that has no C implementation, and stubbing them would claim an enforcement this backend cannot make. THE #72 LIMITATION IS NOW CLOSED. file_gate_parity.rs's active SECURITY REGRESSION arm was previously unexecutable — no vfs C helpers existed to build the bypass with — and is now negative-controlled: deleting a single riina_gate call from the emitted fail_tulis makes the compiled binary perform the write (exit 0) and the test fails with its intended message. Adds file_differential.rs (7 cases), chosen to separate the resolution arms rather than to demonstrate the happy path: owner write; non-owner read ALLOWED at 0644; non-owner write and append DENIED; delete clears ownership so a different uid may re-create and own; ungated existence check. A C gate that collapsed owner/group/other by always consulting perm_owner would pass a naive same-uid test and fail these. Also updates pkg_build's interpreter-only boundary example from fail_baca (now compiles) to vfs_baca — a deliberate choice rather than the next arbitrary one, since the VirtualFs quota has no C implementation and so it should stay interpreter-only for as long as that holds. The test's own comment asks for exactly this update. Verified: 03_PROTO 3330/0 (+7), 05_TOOLING 323/0, clippy clean on both, audit-docs.sh 0 discrepancies. STDLIB.md regenerated from the compiler: fail_* and the two setters native-only, the VirtualFs trio still interp-only.
Regenerated by the pre-push riinac verify --full at 512c6ba. Rust tests 3323 -> 3330: the +7 are file_differential.rs, which pins the mirrored C gate against the interpreter's across the owner/group/other resolution arms. Coq recompiled clean at 331 .vo, 0 admits, 0 axioms.
ib823
marked this pull request as ready for review
August 20, 2026 00:16
ib823
pushed a commit
that referenced
this pull request
Aug 20, 2026
Metrics unchanged in substance (3330 tests, 12678 Qed, 0 admits, 0 axioms); timestamp, commit stamp and the rebuilt WASM asset only. Published count is correct — the fail-closed guard added after the #69 chain held for the third consecutive chain. dim1_dim9 promotion_ready reads true for the container-local reason recorded at the #68/#69 chains (pinned jars on disk here). Top-line verdict unchanged: overall_foundation FAIL.
ib823
pushed a commit
that referenced
this pull request
Aug 20, 2026
… it exposed REQ-70. Routes the remaining 17 security builtins, taking the family from 24/42 to 41/42. Getting there required fixing two divergences in families the plan had already marked closed -- both invisible to the differentials those families shipped with, and both prerequisites rather than side quests. THE DEFERRAL WAS WRONG, AND CHECKING IT IS WHAT UNBLOCKED THIS. The previous increment routed only the single-argument subset, reasoning that eleven members take a pair and split_pair hands back a Value::BuiltinPartial for a non-pair argument, which the C backend has no equivalent of. That reasoning does not hold: every one of those signatures is typed Ty::Prod(..) -> _ in riina-typechecker, so the curried form f(a, b) is REJECTED AT TYPE-CHECK, identically under `riinac run` and `riinac build`, and only f((a, b)) ever reaches a runtime. The interpreter's partial arm is unreachable from well-typed source, so C needing no partial-application machinery costs nothing. A test now pins that, because it is the assumption the eleven C implementations rest on. PREREQUISITE 1 -- THE C JSON PARSER COULD NOT FAIL. riina_json_parse_value had no error path at all: unknown input fell through to strtoll and became a value. Five classes, each SILENT -- input riinac run compiled binary "xyz" unexpected char 'x' 0 "" unexpected end of input 0 "12abc" unexpected trailing content 12 "nul" expected 'null' () "[1,2" expected ',' or ']' in array [1,2] A compiled program parsing attacker-controlled JSON saw a FABRICATED value where the interpreter refuses. json_differential missed it because all nine of its cases fed WELL-FORMED input, so a parser that cannot fail was never asked to. And json_parse_safe/nyahsiri_selamat could not be routed at all: their whole contract is "malformed input yields Unit", and "malformed" had no meaning on the C side. The parser now mirrors builtins/json.rs production-for-production, including the parts that are not obviously deliberate: whitespace is UNICODE (str::trim), not the ASCII four; a lone surrogate in a \uXXXX escape decodes to nothing (char::from_u32 is None and the interpreter pushes no character); and a number is read as u64 first, then as f64 with Rust's SATURATING as-u64 cast -- so "-5" is 0, where strtoll plus a C cast gave 18446744073709551611. Failure is recorded rather than raised in place, so json_urai can report it and json_parse_safe can swallow it. PREREQUISITE 2 -- COMPOSITE VALUES RENDERED AS THE LITERAL TEXT <value>. riina_format handled the scalar tags and defaulted every composite one -- PAIR, LIST, MAP and both SUM arms -- to "<value>". cetakln and ke_teks both go through it, so a compiled program printing a list showed <value> where riinac run shows [1, 2, 3]. That also made sahkan_panjang unroutable in practice: it returns an Option, so its answer was unobservable in compiled code -- lowering it would have been the REQ-79 "compiles but you cannot see the result" trap. ke_teks additionally carried its own COPY of the scalar arms, which is how the two came to disagree; it now delegates, so there is one switch rather than two. The interpreter has two rendering modes that spell values differently: builtins::format_value prints a string bare and a bool as betul/salah, while Value's Display -- which format_value falls through to, and which is the ONLY path a sum takes -- quotes the string and prints Rust's English true/false. So the same bool is betul in a list and true in a sum. That is an inconsistency in the reference, not a design, but the reference is what a compiled program must match; both modes are mirrored rather than tidied, and pinned so that changing it is a language decision and not codegen drift. ROUTED: seven pair-taking modelled sinks (dom_set_html/attr, email_send/ set_header, http_post/put/delete), validate_length, the three CSRF predicates, the safe-file trio, and the three safe parsers. The safe-file trio share the VERIFIED GATE with fail_* -- the interpreter calls the same fail::gate_read/gate_write/gate_delete -- so the emitted C reaches the same riina_gate. A "safe" file op that skipped the access check would be the REQ-72 bypass wearing the word `selamat`. They were routable only because the gate landed in #73. NOT ROUTED: csrf_generate/csrf_jana, the single remaining member. Its result is not a function of its input (a token seeded from the clock and a process-local counter), so the backends cannot be held to agreement by a differential, only to a shape. Mirroring it would mean transcribing Rust's DefaultHasher into C to reproduce a generator its own doc comment already marks as "a *reference* token, not a certified CSPRNG" -- spreading that to a second implementation makes the eventual fix twice the work. Nothing is cut off: csrf_validate takes plain Teks, so a compiled program can carry tokens minted elsewhere, unlike the sanitizers which would have been unreachable without baca_baris. RECORDED, NOT FIXED -- a stdlib defect the differential surfaced. sanitasi_json is the only producer of Disanitasi<Teks, JsonValidation> and so the only way to reach json_urai_selamat, but it is a string-EMBEDDING escaper: it turns {"a":1} into {\"a\":1}. Every JSON object therefore arrives malformed and parses to Unit, because object keys are quoted; only quote-free documents survive. Both backends agree on this, so it is a type-signature defect in the security stdlib -- the gate on a safe PARSER should be a validation, not an escape -- and not a divergence. Pinned in both directions so a fix has to update the test deliberately. Tests: keselamatan_differential 14 -> 24, json_differential 9 -> 13, collection_differential 7 -> 9. The keselamatan additions cover the empty allowed-origin arms (a C author reaching for strncmp alone would accept every origin), character-vs-byte length counting, gate parity for the safe-file trio including a denied non-owner write, and the safe parsers on each malformed class. Verified: 03_PROTO 3360/0 (+30), 05_TOOLING 323/0, clippy clean on both, audit-docs.sh 0 discrepancies. STDLIB.md regenerated from the compiler.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The file family was held back in #72 because routing it would have let a compiled binary bypass a Coq-modeled access-control check that
riinac runenforces — 12 gate call sites in the interpreter, 0 gating constructs in the C helpers. The gate is now mirrored, so the family routes.A compiled binary now refuses a non-owner write exactly as the interpreter does, with the file left unchanged.
Design — recorded because the obvious approach is impossible
Emitted C cannot call into Rust. The pipeline is
cc -o out one.cwith nothing linked, so a single shared implementation callable from both backends would mean shipping per-target Rust staticlibs (native / wasm32 / android-arm64 / ios-arm64) and rebuilding the compile pipeline. I recommended that ABI earlier; it was not buildable, and this corrects it.Instead the Coq model is the single source of truth —
domains/VerifiedFileSystem.v(Inode/Ownership/Permission/is_owner/get_permission) — withriina-os/src/vfs.rsand the emitted C as two implementations of it, held together by a differential. The same shape as themasacivil calendar (#70) and the GF128/AES Coq⇄Rust equivalences.The C mirrors: the inode table, first-touch ownership at mode 0644, owner ▷ group ▷ other resolution,
can_read/can_writeasis_root ||the applicable bit, andgate_deleteclearing the mapping so a re-creator owns the file. Denial exits non-zero with the interpreter's wording and cannot fall through to the filesystem.Matched op-for-op, including the negative space.
fail_adaandfail_senaraiare ungated in the interpreter — an existence check is not an access — so they are ungated in C. Gating them would have been a divergence in the opposite direction: denying what the interpreter allows.Deliberately not the host OS's permission bits. The gate is RIINA's model and must deny where the model denies even when the host would allow (the process typically owns these files). Letting the kernel decide would silently make the check a no-op.
Scope
Routed: the 8
fail_*builtins plusvfs_mula/vfs_jadi_pengguna, which are what make the gate meaningful.vfs_tulis/vfs_baca/vfs_padamstay interpreter-only — they operate on the in-memory VirtualFs with quota accounting that has no C implementation, and stubbing them would claim an enforcement this backend cannot make.The #72 limitation is now closed
file_gate_parity.rs's activeSECURITY REGRESSIONarm was previously unexecutable — novfs_*C helpers existed to build the bypass with — and I said so in that PR rather than implying it was verified. It is now negative-controlled:Testing
cargo test --all(03_PROTO): 3330/0 (+7); (05_TOOLING): 323/0cargo clippy --all-targets -- -D warningsclean on both workspacesaudit-docs.sh0 discrepanciesfile_differential.rs, 7 casesThe differential cases separate the resolution arms rather than demonstrating the happy path — a C gate that collapsed owner/group/other by always consulting
perm_ownerwould pass a naive same-uid test and fail these:perm_ownerperm_other.readat 0644perm_other.writefalsefail_tambahcall sitefail_panjang/fail_baca_barisread gatesgate_deletedrops the mapping; a different uid re-creates and ownsAlso updates
pkg_build's interpreter-only boundary example fromfail_baca(now compiles) tovfs_baca— a deliberate choice, since the VirtualFs quota has no C implementation and so it should stay interpreter-only for as long as that holds. That test's own comment asks for exactly this update.docs/api/STDLIB.mdregenerated from the compiler:fail_*and the two settersnative-only, the VirtualFs trio stillinterp-only.Type
.riifileChecklist
unsafeAdmittedin Coq proofs (no proof changes; the model was already there)Related:
keselamataninspected, and it is not a second file familyRecorded in the REQ-70 row. Its taint/sink discipline is enforced at compile time by the type system — verified by running it, not inferred:
That fires before a backend is chosen, so compiling cannot bypass it. Only 3 of 42 carry a runtime security property — the
fail_*_selamattrio, which call the same gate and are therefore covered by this PR.emit.rshas zero C helpers for anykeselamatanbuiltin, so there is no pre-written ungated code to route by accident. Routing the remaining 39 is ordinary work.One incidental finding:
csrf_janaderives tokens fromSystemTimenanos throughDefaultHasher— predictable, not a CSPRNG. Honestly disclaimed in the source, but the caveat does not reachSTDLIB.md, where a caller sees onlyFn((), Teks, Rawak). Same REQ-47 boundary-disclosure pattern; cheap to fix with a generated caveat.Generated by Claude Code